Repository navigation
Fuzz the executor against a real Postgres, and record what it found - #122
Merged
Merged
Conversation
tests/test_sqlglot_workarounds.py was written by hand, one tripwire per bug somebody happened to trip over. That finds the bugs on the paths we walk. It says nothing about the paths we don't, and the two workarounds that silently outlived their cause (#50) are evidence that noticing is not a strategy. So: tools/fuzz generates random SELECTs, runs each against a real PostgreSQL and against the executor, and reports the divergences minimised to the smallest query that still shows them. Four things it has to get right, one module each. Validity, not randomness, is the hard part -- a generator that emits garbage spends its budget on syntax errors. generate.py is typed and knows the handful of things Postgres rejects at runtime rather than at parse time (division by zero, mod() on a double precision, a FULL JOIN on a non-hashable condition). About 1% of what it emits is still invalid and those samples are discarded, which is also what lets shrink.py propose reckless variants: Postgres is the validity filter. Grading, because Postgres and the executor disagree constantly in ways that are not bugs. avg(int) is a Decimal on one side and a float on the other; a comparison that calls that a failure buries the real findings under it. compare.py ranks COUNT and VALUE above EXACT and TYPE, and compares floats within a relative tolerance since both engines do the same arithmetic in a different order. Shrinking, without which the tool is useless: 2817 raw failures on the first run were 212 buckets, each otherwise buried under forty lines of generated arithmetic. Minimised queries de-duplicate themselves, two seeds that found one bug converging on one text. And knowing whose bug it is. The same executor is reachable either side of tables.py, so every finding reports whether it also fails raw sqlglot. Four did not: our own rewrites are what break them. Fourteen new upstream bugs are tripwires here, all still failing against main at d138cae. Six are wrong answers rather than refusals, and three of those cannot be detected from the answer: count(DISTINCT x) ignores the DISTINCT, SELECT DISTINCT a, a answers one column instead of two, and an outer join's NULL-padded row survives a WHERE that tests the padded column. Text collation is the one finding with no fix. The executor compares strings by code point and Postgres by its database collation, so 'abc' < 'ABC' disagrees on any locale-derived collation and every ORDER BY divergence on the first run was that. It has no fix from outside -- there is no collation for the executor to consult -- so it is documented in docs/limitations.md and the README says to create the oracle with --locale=C to see past it. Nothing here runs in CI. It needs a live server, and a suite that fails on a new random seed is a suite people delete. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 14, 2026
The first version asserted count(DISTINCT a) over 1, 1, 2 -- which cannot tell "ignores the DISTINCT" (count(a)) from "counts rows" (count(*)), because with no NULL in the column those are the same number. With one, the executor answers 4, so it is count(*): both the DISTINCT and the NULL-skipping are lost. It is also not only count. sum(DISTINCT x) and avg(DISTINCT x) raise, because the cause is one line of codegen -- the Python generator renders exp.Distinct as set(), throwing the expression away, so COUNT(DISTINCT a) compiles to COUNT(set()). That reclassifies the bug from planner-level to a single handler, which is why #58 moves it up to a near-term PR. Measured for the same reason: single-argument count(DISTINCT x) is SQL-92 and agrees exactly across PostgreSQL 18.4, SQLite 3.51 and MySQL 8.0.46, and the executor is wrong under every dialect rather than just postgres. So a fix cannot break a dialect that wanted the old behaviour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
tests/test_sqlglot_workarounds.pywas written by hand, one tripwire per bug somebody happened to trip over. That finds the bugs on the paths we walk. It says nothing about the paths we don't — and the two workarounds that silently outlived their cause (#50) are evidence that noticing is not a strategy.So
tools/fuzzgenerates randomSELECTs, runs each against a real PostgreSQL and against the executor, and reports the divergences minimised to the smallest query that still shows them.createdb pg_mimic_fuzz && python -m tools.fuzz --count 4000Nothing here runs in CI. It needs a live server, and a suite that fails on a new random seed is a suite people delete. Its output is what runs in CI: fourteen new tripwires in this diff.
Four things it has to get right
Validity, not randomness, is the hard part. A generator that emits garbage spends its budget on syntax errors.
generate.pyis typed, and knows the handful of things Postgres rejects at runtime rather than at parse time: division andmod()by zero,mod()and two-argumentround()on adouble precision, aFULL JOINon a non-hashable condition. About 1% of what it emits is still invalid and those samples are discarded — which is also what lets the shrinker be reckless. Postgres is the validity filter.Grading, because Postgres and the executor disagree constantly in ways that are not bugs.
avg(int)is aDecimalon one side and afloaton the other; a comparison that calls that a failure buries the real findings under it.compare.pyranksCOUNT/VALUE(always bugs) aboveEXACT/TYPE, and compares floats within a relative tolerance, since both engines do the same arithmetic in a different order and are allowed to land a few ULPs apart.Shrinking, without which the tool is useless. 2817 raw failures on the first run were 212 buckets, each otherwise buried under forty lines of generated arithmetic. The moves are deliberately reckless — hoist any node to its own child, replace any node with a literal, drop any clause — because a variant Postgres refuses is rejected exactly like one that stops reproducing. Minimised queries then de-duplicate themselves, two seeds that found one bug converging on one text.
Knowing whose bug it is. The same executor is reachable either side of
tables.py, so every finding reports whether it also fails raw sqlglot: also fails raw is upstream reaching our users, raw gets this right is one of our own rewrites breaking it.What it found
Fourteen new upstream bugs, all still failing against sqlglot main at
d138cae. Six are wrong answers rather than refusals, and three of those cannot be detected from the answer:count(DISTINCT x)ignores the DISTINCT entirelySELECT DISTINCT a, aanswers one column;SELECT DISTINCT x.a, y.aover a join answers 3 rows where Postgres answers 9. Same root cause as A derived table's or CTE's own columns are named by qualify(), not as Postgres names them #113 —qualify()names both_col_0— but the consequence is lost data, not a wrong labelWHERE x.a = 1Plus
CAST(0.5 AS INT)truncating where Postgres rounds (by the same two rules asround()),LIKE 'a\%b'ignoring the escape,EXISTS (subquery)emitting invalid Python,NULL IN (subquery)answering FALSE rather than NULL, andDecimal * floatraisingTypeError— that last needs no join, subquery or unusual function, just two numeric columns of different types, so it is the likeliest of these to be the first thing a user hits.Details and the upstreaming split go on #58. Four findings are ours, not upstream —
_rewrite_not_inturns a correct answer into a wrong one — and get their own issue rather than riding along here, since this diff adds no test for them.Text collation is the one finding with no fix
The executor compares strings by code point and Postgres by its database collation, so
'abc' < 'ABC'disagrees on any locale-derived collation, and everyORDER BYdivergence on the first run was that. There is no collation for the executor to consult, so this is a modelling gap: documented indocs/limitations.md, with the README notingcreatedb --locale=Cto see past it. With C collation, 13 ordering buckets became 1 real bug.Notes for review
docs/limitations.mdis the only user-facing change; everything else is a dev tool and tests.xfail(strict=True)like their neighbours, so the suite is green on the pinned floor and goes red the moment upstream fixes one.tools/is not packaged ([tool.hatch.build.targets.wheel] packages = ["pg_mimic"]), so none of this ships in the wheel.